import Tether from 'tether';
import listenOnRootMixin from './listen-on-root'
import { isArray, arrayIncludes } from '../utils/array';
import { keys } from '../utils/object';

// Controls which events are mapped for each named trigger, and the expected popover behavior for each.
const TRIGGER_LISTENERS = {
    click: {click: 'toggle'},
    hover: {mouseenter: 'show', mouseleave: 'hide'},
    focus: {focus: 'show', blur: 'hide'}
};
const PLACEMENT_PARAMS = {
    top: 'bottom center',
    bottom: 'top center',
    left: 'middle right',
    right: 'middle left'
};
const TETHER_CLASS_PREFIX = 'bs-tether';
const TETHER_CLASSES = {
    element: false,
    enabled: false
};
const TRANSITION_DURATION = 150;

export default {
    mixins: [listenOnRootMixin],
    props: {
        constraints: {
            type: Array,
            default() {
                return [];
            }
        },
        debounce: {
            type: [Number],
            default: 300,
            validator(value) {
                return value >= 0;
            }
        },
        delay: {
            type: [Number, Object],
            default: 0,
            validator(value) {
                if (typeof value === 'number') {
                    return value >= 0;
                } else if (value !== null && typeof value === 'object') {
                    return typeof value.show === 'number' &&
                        typeof value.hide === 'number' &&
                        value.show >= 0 &&
                        value.hide >= 0;
                }
                return false;
            }
        },
        offset: {
            type: String,
            default: '0 0',
            validator(value) {
                // Regex test for a pair of units, either 0 exactly, px, or percentage
                return /^((0\s?)|([+-]?[0-9]+(px|%)\s?)){2}$/.test(value);
            }
        },
        placement: {
            type: String,
            default: 'top',
            validator: value => arrayIncludes(keys(PLACEMENT_PARAMS), value)
        },
        popoverStyle: {
            type: Object,
            default: null
        },
        show: {
            type: Boolean,
            default: null
        },
        targetOffset: {
            type: String,
            default: '0 0',
            // Regex test for a pair of units, either 0 exactly, px, or percentage
            validator: value => /^((0\s?)|([+-]?[0-9]+(px|%)\s?)){2}$/.test(value)
        },
        triggers: {
            type: [Boolean, String, Array],
            default: () => ['click', 'focus'],
            validator(value) {
                // Allow falsy value to disable all event triggers (equivalent to 'manual') in Bootstrap 4
                if (value === false || value === '') {
                    return true;
                } else if (typeof value === 'string') {
                    return keys(TRIGGER_LISTENERS).indexOf(value) !== -1;
                } else if (isArray(value)) {
                    const triggerKeys = keys(TRIGGER_LISTENERS);
                    value.forEach(item => {
                        if (arrayIncludes(triggerKeys, item)) {
                            return false;
                        }
                    });
                    return true;
                }
                return false;
            }
        }
    },
    data() {
        return {
            triggerState: this.show,
            classState: this.show,
            lastEvent: null
        };
    },
    computed: {
        /**
         * Arrange event trigger hooks as array for all input types.
         *
         * @return Array
         */
        normalizedTriggers() {
            if (this.triggers === false) {
                return [];
            } else if (typeof this.triggers === 'string') {
                return [this.triggers];
            }
            return this.triggers;
        },
        /**
         * Class property to be used for Popover rendering
         *
         * @return String
         */
        popoverAlignment() {
            return !this.placement || this.placement === `default` ? `popover-top` : `popover-${this.placement}`;
        },
        /**
         * Determine if the Popover should be shown.
         *
         * @return Boolean
         */
        showState() {
            return this.show !== false && (this.triggerState || this.show);
        }
    },
    watch: {
        /**
         * Refresh Tether display properties
         */
        constraints() {
            this.setOptions();
        },
        /**
         * Refresh Popover event triggers
         * @param {Array} newTriggers
         * @param {Array} oldTriggers
         */
        normalizedTriggers(newTriggers, oldTriggers) {
            this.updateListeners(newTriggers, oldTriggers);
        },
        /**
         * Refresh Tether display properties
         */
        offset() {
            this.setOptions();
        },
        /**
         * Refresh Tether display properties
         */
        placement() {
            this.setOptions();
        },
        /**
         * Affect 'show' state in response to status change
         * @param  {Boolean} val
         */
        showState(val) {
            const delay = this.getDelay(val);
            clearTimeout(this.$data._timeout);
            if (delay) {
                this.$data._timeout = setTimeout(() => this.togglePopover(val), delay);
            } else {
                this.togglePopover(val);
            }
        }
    },
    methods: {
        /**
         * Add all event hooks for the given trigger
         * @param {String} trigger
         */
        addListener(trigger) {
            // eslint-disable-next-line guard-for-in
            for (const item in TRIGGER_LISTENERS[trigger]) {
                this.$data._trigger.addEventListener(item, e => this.eventHandler(e));
            }
        },
        /**
         * Tidy removal of Tether object from the DOM
         */
        destroyTether() {
            if (this.$data._tether && !this.showState) {
                this.$data._tether.destroy();
                this.$data._tether = null;
                const regx = new RegExp('(^|[^-]\\b)(' + TETHER_CLASS_PREFIX + '\\S*)', 'g');
                if (this.$data._trigger && this.$data._trigger.className) {
                    this.$data._trigger.className = this.$data._trigger.className.replace(regx, '');
                }
            }
        },
        /**
         * Handle multiple event triggers
         * @param  {Object} e
         */
        eventHandler(e) {
            // If this event is right after a previous successful event, ignore it (debounce)
            if (this.normalizedTriggers.length > 1 && this.debounce > 0 && this.lastEvent !== null && e.timeStamp <= this.lastEvent + this.debounce) {
                return;
            }
            // Look up the expected popover action for the event
            // eslint-disable-next-line guard-for-in
            for (const trigger in TRIGGER_LISTENERS) {
                for (const event in TRIGGER_LISTENERS[trigger]) {
                    if (event === e.type) {
                        const action = TRIGGER_LISTENERS[trigger][event];
                        // If the expected event action is the opposite of the current state, allow it
                        if (action === 'toggle' || (this.triggerState && action === 'hide') || (!this.triggerState && action === 'show')) {
                            this.triggerState = !this.triggerState;
                            this.lastEvent = e.timeStamp;
                        }
                        return;
                    }
                }
            }
        },
        /**
         * Get the currently applicable popover delay
         *
         * @returns Number
         */
        getDelay(state) {
            if (typeof this.delay === 'object') {
                return state ? this.delay.show : this.delay.hide;
            }
            return this.delay;
        },
        /**
         * Tether construct params for each show event.
         *
         * @return Object
         */
        getTetherOptions() {
            return {
                attachment: PLACEMENT_PARAMS[this.placement],
                element: this.$data._popover,
                target: this.$data._trigger,
                classes: TETHER_CLASSES,
                classPrefix: TETHER_CLASS_PREFIX,
                offset: this.offset,
                constraints: this.constraints,
                targetOffset: this.targetOffset
            };
        },
        /**
         * Hide popover and fire event
         */
        hidePopover() {
            this.classState = false;
            clearTimeout(this.$data._timeout);
            this.$data._timeout = setTimeout(() => {
                this.$data._popover.style.display = 'none';
                this.destroyTether();
            }, TRANSITION_DURATION);
        },
        /**
         * Refresh the Popover position in order to respond to changes
         */
        refreshPosition() {
            if (this.$data._tether) {
                this.$nextTick(() => {
                    this.$data._tether.position();
                });
            }
        },
        /**
         * Remove all event hooks for the given trigger
         * @param {String} trigger
         */
        removeListener(trigger) {
            // eslint-disable-next-line guard-for-in
            for (const item in TRIGGER_LISTENERS[trigger]) {
                this.$data._trigger.removeEventListener(item, e => this.eventHandler(e));
            }
        },
        /**
         * Update tether options
         */
        setOptions() {
            if (this.$data._tether) {
                this.$data._tether.setOptions(this.getTetherOptions());
            }
        },
        /**
         * Display popover and fire event
         */
        showPopover() {
            clearTimeout(this.$data._timeout);
            if (!this.$data._tether) {
                this.$data._tether = new Tether(this.getTetherOptions());
            }
            this.$data._popover.style.display = 'block';
            // Make sure the popup is rendered in the correct location
            this.refreshPosition();
            this.$nextTick(() => {
                this.classState = true;
            });
        },
        /**
         * Handle Popover show or hide instruction
         */
        togglePopover(newShowState) {
            this.$emit('showChange', newShowState);
            if (newShowState) {
                this.showPopover();
                this.emitOnRoot('shown::popover');
            } else {
                this.hidePopover();
                this.emitOnRoot('hidden::popover');
            }
        },
        /**
         * Study the 'triggers' component property and apply all selected triggers
         * @param {Array} triggers
         * @param {Array} appliedTriggers
         */
        updateListeners(triggers, appliedTriggers = []) {
            const newTriggers = [];
            const removeTriggers = [];
            // Look for new events not yet mapped (all of them on first load)
            triggers.forEach(item => {
                if (appliedTriggers.indexOf(item) === -1) {
                    newTriggers.push(item);
                }
            });
            // Disable any removed event triggers
            appliedTriggers.forEach(item => {
                if (triggers.indexOf(item) === -1) {
                    removeTriggers.push(item);
                }
            });
            // Apply trigger mapping changes
            newTriggers.forEach(item => this.addListener(item));
            removeTriggers.forEach(item => this.removeListener(item));
        }
    },
    created() {
        this.listenOnRoot('hide::popover', () => {
            this.triggerState = false;
        });
    },
    mounted() {
        // Configure tether
        this.$data._trigger = this.$refs.trigger.children[0] || this.$refs.trigger;
        this.$data._popover = this.$refs.popover;
        this.$data._popover.style.display = 'none';
        this.$data._tether = new Tether(this.getTetherOptions());
        this.$data._timeout = 0;
        // Add listeners for specified triggers and complementary click event
        this.updateListeners(this.normalizedTriggers);
        // Display popover if prop is set on load
        if (this.showState) {
            this.showPopover();
        }
    },
    updated() {
        this.refreshPosition();
    },
    beforeDestroy() {
        this.normalizedTriggers.forEach(item => this.removeListener(item));
        clearTimeout(this.$data._timeout);
        this.destroyTether();
    },
    destroyed() {
        // Tether is moving the popover element outside of Vue's control and leaking dom nodes
        if (this.$data._popover.parentElement === document.body) {
            document.body.removeChild(this.$data._popover);
        }
    }
};
